/*
 * Copyright (c) 2012 Sean Connelly
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
 * associated documentation files (the "Software"), to deal in the Software without restriction,
 * including without limitation the rights to use, copy, modify, merge, publish, distribute,
 * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all copies or
 * substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
 * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
 * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
 * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */

var textStream = require('./text-stream.js');
var err = require('./err.js');
var warn = require('./warn.js');
var token = require('./token.js');
var config = require('./config.js');
var common = require('./common.js');
var lexRatioParts = require('./lex-ratio-parts.js');

//
// converts a text stream into a list of tokens
//

function lexRho_categorizeIdent(marker, ident, extended)
{
	// rhox files add the rhox keyword
	if (extended && ident == 'rhox')
		return token.Keyword(marker, ident);

	if (ident.length > config.maxIdentLength)
		return token.Error(marker, err.IdentifierTooLong(ident, ident.length));
	// must check for isRhodent first, because all Rhodents are Keywords
	if (config.isRhodent(ident))
		return token.Rhodent(marker, ident);
	if (config.isKeyword(ident))
		return token.Keyword(marker, ident);
	return token.Ident(marker, ident);
}

function lexRho_createLexObj(ts, extended)
{
	var validPrefix = config.versionPrefix;
	if (extended)
		validPrefix = config.extendedPrefix;

	var exeState = {
			// find the version at the start of the file
			FindVersion: function(isFinal) {
					if (isFinal)
					{
						this.addToken(token.Error(this.stream.markSpot(), err.MissingVersion()));
						return exeState.Done;
					}

					if (this.charsMatch(validPrefix))
					{
						this.stream.pushIncluding() // save the location of the version
							.skipChars(validPrefix.length - 1); // skip over extra chars
						this.str = '';
						return exeState.ReadVersion;
					}

					// didn't find it, so keep looking
					return exeState.FindVersion;
				},

			// read the version from the file
			ReadVersion: function(isFinal) {
					function validateVersion(str)
					{
						// MYTODO: figure out if str is valid
						// str is something like "v1.0"
					}

					if (isFinal)
					{
						validateVersion(this.str);
						// add the version to tokens, or an error otherwise
						this.addToken(token.Version(this.stream.popIncluding(), validPrefix, this.str));
						return exeState.Done;
					}

					// if white space, then we've finished reading version
					if (common.iscWhitespace(this.ch))
					{
						validateVersion(this.str);
						// add the version to tokens, or an error otherwise
						this.addToken(token.Version(this.stream.popExcluding(), validPrefix, this.str));
						return exeState.Init;
					}

					// otherwise, record the version, and stay here
					this.str += this.ch;
					return exeState.ReadVersion;
				},

			// main start state used to figure out where to go to next
			Init: function(isFinal) {
					if (isFinal) // if we are at EOF
					{
						if (this.stringQuote.length > 0) // check to see if we're inside a string
						{
							// MYTODO: add an error because we're inside a string
						}
						return exeState.Done; // then we're done... no big deal
					}

					var nextChars = [
							this.getCharLen(1), // will hold the next 1 character
							this.getCharLen(2), // will hold the next 2 characters
							this.getCharLen(3)  // will hold the next 3 characters
						];

					// check for plain white space
					if (common.isWhite(nextChars[0]))
						return exeState.Init; // ignore it, and just continue onwards

					// check for 3, 2, and 1 character specials
					for (var special_len = 2; special_len >= 0; special_len--)
					{
						if (nextChars[special_len] !== false && config.isSpecial(nextChars[special_len]))
						{
							// if we have a special that adds to stacking braces, then push to stringQuote
							if (config.isStackingBrace(nextChars[special_len]))
								this.stringQuote.unshift(false);
							else if (nextChars[special_len] === '}' && this.stringQuote.length > 0) // end brace
							{
								this.stringQuote.shift();
								if (this.stringQuote.length > 0 && this.stringQuote[0] !== false)
								{
									// we're back inside a quoted literal
									var expr_marker = this.stream.popIncluding();
									this.addToken(token.EndInnerExpr(expr_marker));
									this.stream.pushIncluding();
									this.str = '';
									return exeState.String;
								}
							}

							// mark the text, skip over the remaining chars, and add the Special token
							this.stream.pushIncluding().skipChars(special_len);
							this.addToken(token.Special(this.stream.popIncluding(), nextChars[special_len]));
							return exeState.Init; // stay in this state
						}
					}

					// check for macro MYTODO: bug!! this will never hit because "#{" is a special!
					if (nextChars[config.macroStart.length - 1] === config.macroStart)
					{
						this.stream.pushIncluding() // save the text
							.skipChars(config.macroStart.length - 1); // skip over the extra characters
						this.macroBraceCount = 0;
						this.str = config.macroStart;
						return exeState.MacroBody;
					}

					// check for block comment
					if (nextChars[config.blockCommentStart.length - 1] === config.blockCommentStart)
					{
						this.stream.pushIncluding() // save the text
							.skipChars(config.blockCommentStart.length - 1); // skip over the extra chars
						this.str = config.blockCommentStart;
						return exeState.CommentBlock;
					}

					// MYTODO: perhaps check for blockCommentEnd, and give a nice error message

					// check for nesting comment
					if (nextChars[config.nestCommentStart.length - 1] === config.nestCommentStart)
					{
						this.stream.pushIncluding() // save the text
							.skipChars(config.nestCommentStart.length - 1); // skip over the extra chars
						this.nestCommentCount = 0;
						this.str = config.nestCommentStart;
						return exeState.CommentNest;
					}

					// MYTODO: perhaps check for nestCommentEnd, and give a nice error message

					// check for rhotext
					if (nextChars[config.rhotextStart.length - 1] === config.rhotextStart)
					{
						this.stream.pushIncluding() // save the text
							.skipChars(config.rhotextStart.length - 1); // skip over the extra chars
						this.str = config.rhotextStart;
						return exeState.Rhotext;
					}

					// check for line comment
					if (nextChars[0] === ';')
					{
						this.stream.pushIncluding();
						this.str = ';';
						return exeState.CommentLine;
					}

					// check for strings
					if (nextChars[0] === '"' || nextChars[0] === '\'')
					{
						if (nextChars[0] === '"')
							this.addToken(token.BeginText(this.stream.markChar()));
						else // blob
							this.addToken(token.BeginBlob(this.stream.markChar()));
						this.stream.pushExcluding();
						this.str = '';
						this.stringQuote.unshift(nextChars[0]);
						return exeState.String;
					}

					// check for numbers
					if (common.isDigit(nextChars[0]))
					{
						this.pause(1); // pause for one character, and re-process in the Number state
						this.stream.pushIncluding();
						this.str = '';
						return exeState.Number;
					}

					// check for identifiers
					if (common.isIdentStart(nextChars[0]))
					{
						this.pause(1); // pause for one character, and re-process in the Ident state
						this.stream.pushIncluding();
						this.str = '';
						return exeState.Ident;
					}

					// invalid character, so add an error, and move on
					this.addToken(token.Error(this.stream.markChar(),
						err.InvalidCharacter(nextChars[0])));
					return exeState.Init;
				},

			CommentLine: function(isFinal) {
					if (isFinal || this.ch === '\r' || this.ch === '\n') // if at EOF, or newline
					{
						// add the CommentLine token
						this.addToken(token.CommentLine(this.stream.popIncluding(), this.str));
						// and leave
						return isFinal ? exeState.Done : exeState.Init;
					}

					// otherwise, save the comment text, and stay in this state
					this.str += this.ch;
					return exeState.CommentLine;
				},

			CommentBlock: function(isFinal) {
					if (isFinal) // if EOF, error out
					{
						this.addToken(token.Error(this.stream.markSpot(), err.MissingEndCommentBlock()));
						return exeState.Done;
					}

					// if we found another block comment in here, error out
					if (this.charsMatch(config.blockCommentStart))
					{
						this.addToken(token.Error(this.stream.markChar(), err.CannotNestBlockComment()));
						return exeState.CommentBlock;
					}

					// if we found an end block comment, skip over it, and return to Init
					if (this.charsMatch(config.blockCommentEnd))
					{
						this.str += config.blockCommentEnd;
						this.stream.skipChars(config.blockCommentEnd.length - 1);
						this.addToken(token.CommentBlock(this.stream.popIncluding(), this.str));
						return exeState.Init;
					}

					// inside block comment, so just record the text, and stay in this state
					this.str += this.ch;
					return exeState.CommentBlock;
				},

			CommentNest: function(isFinal) {
					if (isFinal) // if EOF, error out
					{
						this.addToken(token.Error(this.stream.markSpot(), err.MissingEndCommentNest()));
						return exeState.Done;
					}

					// if we found another nest comment in here, increase the counter
					if (this.charsMatch(config.nestCommentStart))
					{
						this.str += config.nestCommentStart;
						this.stream.skipChars(config.nestCommentStart.length - 1);
						this.nestCommentCount += 1;
						return exeState.CommentNest;
					}

					// if we found an end nest comment, decrease the counter
					if (this.charsMatch(config.nestCommentEnd))
					{
						this.str += config.nestCommentEnd;
						this.stream.skipChars(config.nestCommentEnd.length - 1);
						this.nestCommentCount -= 1;
						// check for closing of the last nested comment
						if (this.nestCommentCount <= 0)
						{
							this.addToken(token.CommentNest(this.stream.popIncluding(), this.str));
							return exeState.Init;
						}
					}

					// inside nest comment, so just record the text, and stay in this state
					this.str += this.ch;
					return exeState.CommentNest;
				},

			Ident: function(isFinal) {
					if (isFinal) // if EOF, add the token, go to done
						throw 'internal error; Ident should never receive EOF signal on current char';

					this.str += this.ch;

					if (this.nch === false || !common.isIdentMiddle(this.nch))
					{
						this.addToken(lexRho_categorizeIdent(this.stream.popIncluding(), this.str, extended));
						return exeState.Init;
					}

					return exeState.Ident;
				},

			String: function(isFinal) {
					var my = this;
					var isText = this.stringQuote[0] === '"';
					var addLiteral = function(marker) {
							if (my.str === '') // don't add empty strings
								return;
							if (isText)
								my.addToken(token.Text(marker, my.str));
							else
								my.addToken(token.Blob(marker, my.str));
						};

					if (isFinal)
					{
						addLiteral(this.stream.popIncluding());
						this.addToken(token.Error(this.stream.markSpot(), err.UnterminatedString()));
						return exeState.Done;
					}

					if (this.ch === '\r' || this.ch === '\n')
					{
						addLiteral(this.stream.popExcluding());
						this.addToken(token.Error(this.stream.markSpot(), err.UnterminatedString()));
						return exeState.Init;
					}

					if (this.charsMatch(config.stringInterpStart))
					{
						addLiteral(this.stream.popExcluding());

						var marker = this.stream
							.pushIncluding()
							.skipChars(config.stringInterpStart.length - 1)
							.popIncluding();
						this.addToken(token.BeginInnerExpr(marker));

						this.stream.pushExcluding();
						this.stringQuote.unshift(false);
						return exeState.Init;
					}

					if (this.ch === '\\')
					{
						this.stream.pushIncluding();
						return exeState.StringEscape;
					}

					if (this.ch === this.stringQuote[0])
					{
						// MYTODO: confirm this + lots of testing
						addLiteral(this.stream.popExcluding());
						if (isText)
							this.addToken(token.EndText(this.stream.markChar()));
						else
							this.addToken(token.EndBlob(this.stream.markChar()));
						this.stringQuote.shift(); // remove the quote
						return exeState.Init;
					}

					// MYTODO: validate this.ch is ASCII if this.stringQuote[0] === '\''
					this.str += this.ch;

					return exeState.String;
				},

			StringEscape: function(isFinal) {
					if (isFinal)
					{
						// MYTODO: error out
						return exeState.Done;
					}

					var isText = this.stringQuote[0] === '"';

					if (this.ch == 'n')
						this.str += '\n';
					else if (this.ch == 'r')
						this.str += '\r';
					else if (this.ch == 't')
						this.str += '\t';
					else if (this.ch == '0')
						this.str += '\0';
					else if (this.ch == '\\')
						this.str += '\\';
					else if (this.ch == this.stringQuote[0])
						this.str += this.ch;
					else if (this.ch == 'x' || (isText && (this.ch == 'u' || this.ch == 'U')))
					{
						this.hexChars = this.ch;
						this.hexCount = (this.ch == 'x' ? 2 : (this.ch == 'u' ? 4 : 6));
						return exeState.StringHex;
					}
					else
					{
						// MYTODO: add error
						this.str += this.ch;
					}
					return exeState.String;
				},

			StringHex: function(isFinal) {
					if (isFinal)
					{
						// MYTODO: error
						return exeState.Done;
					}

					if (!common.isHex(this.ch))
					{
						// MYTODO: error
						this.str += '\\' + this.hexChars;
						this.pause(1);
						return exeState.Literal;
					}

					this.hexChars += this.ch;
					this.hexCount -= 1;
					if (this.hexCount <= 0)
					{
						var hex_char = String.fromCharCode(parseInt(this.hexChars.substr(1), 16));
						// MYTODO: validate hex_char is ASCII if Blob
						this.str += hex_char;
						return exeState.String;
					}

					return exeState.StringHex;
				},

			Number: function(isFinal) {
					if (isFinal)
					{
						this.addToken(lexRatioParts.lex(this.stream.popIncluding(), this.str));
						return exeState.Done;
					}

					this.str += this.ch;

					do {
						if (this.nch !== false)
						{
							if (common.isWord(this.nch) || // allow word characters in numbers
								this.nch === '.') // allow decimal points in numbers
								break; // then allow it as part of the number

							if (this.str.substr(0, 2) != '0x') // if not hex...
							{
								if ((this.ch === 'e' || this.ch === 'E') && // ..and last charater is "e"..
									(this.nch === '+' || this.nch === '-')) // ..and we have a "+" or "-"
									break; // then allow the "+"/"-", because it is an exponent
							}
							else // if is hex...
							{
								if ((this.ch === 'p' || this.ch === 'P') && // ..and last character is "p"..
									(this.nch === '+' || this.nch === '-')) // ..and we have a "+" or "-"
									break; // then allow the "+"/"-", because it is an exponent
							}
						}

						// otherwise, our next character is not a number, so leave after this char
						this.addToken(lexRatioParts.lex(this.stream.popIncluding(), this.str));
						return exeState.Init;
					} while (false);

					// break's end up here, which signifys that the next character is part of the number
					return exeState.Number;
				},

			Rhotext: function(isFinal) {
				},

			MacroBody: function(isFinal) {
				},

			Done: function(isFinal) {
					throw 'internal error; this should not happen; exeState.Done was called';
				}
		};

	return {
			stream: ts,
			tokens: [],
			state: exeState.FindVersion, // start by finding the "rho:v1.2" text
			ch: false,
			nch: false,
			nnch: false,
			nnnch: false,
			nestCommentCount: 0,
			macroBraceCount: 0,
			str: '',
			pauseLength: 0,
			stringQuote: [],
			hexChars: '',
			hexCount: 0,

			addToken: function(tok) {
					this.tokens.push(tok);
				},

			// read the next character, plus peek ahead three more
			// return false if EOF, true otherwise
			readNextChar: function() {
					if (this.pauseLength > 0) // if we're paused, just stay at the current character
						this.pauseLength -= 1;
					else
					{
						this.ch = this.stream.nextChar();
						this.nch = this.stream.peekChar(0);
						this.nnch = this.stream.peekChar(1);
						this.nnnch = this.stream.peekChar(2);
						this.nnnnch = this.stream.peekChar(3);
					}
					return this.ch !== false;
				},

			// returns true if the length of characters is available in the buffer
			hasCharLen: function(len) {
					switch (len)
					{
					case 1: return this.ch !== false;
					case 2: return this.ch !== false && this.nch !== false;
					case 3: return this.ch !== false && this.nch !== false && this.nnch !== false;
					case 4: return this.ch !== false && this.nch !== false && this.nnch !== false &&
						this.nnnch !== false;
					case 5: return this.ch !== false && this.nch !== false && this.nnch !== false &&
						this.nnnch !== false && this.nnnnch !== false;
					}
					throw 'unexpected length in lexer object, hasCharLen, ' + len;
				},

			// returns the string of the particular length, based on what's in the buffer, or false
			// otherwise
			getCharLen: function(len) {
					if (!this.hasCharLen(len))
						return false;
					switch (len)
					{
					case 1: return this.ch;
					case 2: return this.ch + this.nch;
					case 3: return this.ch + this.nch + this.nnch;
					case 4: return this.ch + this.nch + this.nnch + this.nnnch;
					case 5: return this.ch + this.nch + this.nnch + this.nnnch + this.nnnnch;
					}
					throw 'this shouldn\'t happen; unexpected length in lexer object, getCharLen, ' +
						len;
				},

			charsMatch: function(str) {
					var ch = this.getCharLen(str.length);
					if (ch === false)
						return false;
					return ch === str;
				},

			// pause processing input for a certain length
			pause: function(len) {
					this.pauseLength += len;
				},

			// finalState is true/false if it is the last call to executeThisState due to running out
			// of characters in text stream
			executeThisState: function(finalState) {
					// call the appropriate exeState function, with the lexer object set to `this`
					this.state = this.state.apply(this, [finalState]);
				}
		};
}

function lexRho_mainLoop(lex_obj)
{
	while (lex_obj.readNextChar()) // while there are still characters to read...
		lex_obj.executeThisState(false); // execute the next state
	lex_obj.executeThisState(true); // execute the last state, as the final state
	return lex_obj.tokens;
}

function lexRho_lex(ts, extended)
{
	return lexRho_mainLoop(lexRho_createLexObj(ts, extended));
}

exports.lex = function(ts) { return lexRho_lex(ts, false); };
exports.lexExtended = function(ts) { return lexRho_lex(ts, true); };

/******** old code below... still needs to be merged... MYTODO ********************/

/*
var rhoLexRho = function(textStream) { // returns an object with nextToken()
					case sts.MacroBody:
						str += ch;
						if (ch == '}')
						{
							macroBraceCount--;
							if (macroBraceCount < 0)
							{
								state = sts.Init;
								tokenBuffer = tokenBuffer.concat(rhoLexMacroTextParts(textStream.popIncluding(), str));
								return tokenBuffer.pop();
							}
						}
						else if (ch == '{')
							macroBraceCount++;

MacroBody isFinal: return rhoToken.Error(textStream.popExcluding(), rhoErr.UnterminatedMacro());
*/
